| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520 |
- 'use client';
- import { useState, useEffect, useCallback, useRef } from 'react';
- import { useParams, useRouter } from 'next/navigation';
- import { useAuth } from '@/lib/auth-context';
- import { assetsApi, commentsApi, AssetWithComments, Asset, Comment, AnnotationData, TranscodeStatus } from '@/lib/api';
- import { Avatar } from '@/components/ui/avatar';
- import { VideoPlayer } from '@/components/video-player/VideoPlayer';
- import { Tool } from '@/components/video-player/AnnotationCanvas';
- import { formatTimecode } from '@/lib/format';
- const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
- const MAX_ANNOTATIONS = 10;
- const STATUS_CONFIG: Record<string, { label: string; colorClass: string; bgClass: string; dotClass: string }> = {
- PENDING_REVIEW: { label: 'Pending Review', colorClass: 'text-warning', bgClass: 'badge-warning', dotClass: 'status-dot-pending' },
- CHANGES_REQUESTED: { label: 'Changes Requested', colorClass: 'text-warning', bgClass: 'badge-warning', dotClass: 'status-dot-changes' },
- APPROVED: { label: 'Approved', colorClass: 'text-success', bgClass: 'badge-success', dotClass: 'status-dot-approved' },
- REJECTED: { label: 'Rejected', colorClass: 'text-danger', bgClass: 'badge-danger', dotClass: 'status-dot-rejected' },
- };
- const TRANSCODE_CONFIG: Record<TranscodeStatus, { label: string; color: string; bg: string; spinner: boolean }> = {
- PENDING: { label: 'Queued', color: '#94A3B8', bg: 'rgba(148,163,184,0.08)', spinner: false },
- UPLOADING: { label: 'Uploading video…', color: '#60A5FA', bg: 'rgba(96,165,250,0.08)', spinner: true },
- PROCESSING: { label: 'Transcoding…', color: '#A78BFA', bg: 'rgba(167,139,250,0.08)', spinner: true },
- COMPLETED: { label: 'Ready', color: '#34D399', bg: 'rgba(52,211,153,0.08)', spinner: false },
- FAILED: { label: 'Transcode failed', color: '#F87171', bg: 'rgba(248,113,113,0.08)', spinner: false },
- UNSUPPORTED_CODEC: { label: 'Unsupported codec', color: '#FBBF24', bg: 'rgba(251,191,36,0.08)', spinner: false },
- };
- export default function ReviewPage() {
- const params = useParams();
- const assetId = params.assetId as string;
- const { token, user } = useAuth();
- const router = useRouter();
- const [asset, setAsset] = useState<AssetWithComments | null>(null);
- const [comments, setComments] = useState<Comment[]>([]);
- const [loading, setLoading] = useState(true);
- const [currentTime, setCurrentTime] = useState(0);
- const [panelWidth, setPanelWidth] = useState(380);
- const [commentPanelCollapsed, setCommentPanelCollapsed] = useState(false);
- const [showApproval, setShowApproval] = useState(false);
- const [updatingStatus, setUpdatingStatus] = useState(false);
- const [newComment, setNewComment] = useState('');
- const [submitting, setSubmitting] = useState(false);
- const [replyTo, setReplyTo] = useState<Comment | null>(null);
- const [showResolved, setShowResolved] = useState(false);
- // Drawing state — lifted to page level
- const [drawMode, setDrawMode] = useState(false);
- const [drawTool, setDrawTool] = useState<Tool>('arrow');
- const [drawColor, setDrawColor] = useState('#ef4444');
- const [pendingStrokes, setPendingStrokes] = useState<AnnotationData[]>([]);
- // The comment we're annotating (null = annotating the main video, not a specific comment)
- const [annotatingComment, setAnnotatingComment] = useState<Comment | null>(null);
- // Portrait / landscape detection
- const [isPortrait, setIsPortrait] = useState(false);
- // ── Side-by-side compare mode ────────────────────────────────────────────
- const [compareMode, setCompareMode] = useState(false);
- const [compareAsset, setCompareAsset] = useState<Asset | null>(null);
- const [showComparePicker, setShowComparePicker] = useState(false);
- const [projectAssets, setProjectAssets] = useState<Asset[]>([]);
- const [compareMismatch, setCompareMismatch] = useState<string | null>(null);
- const [compareComments, setCompareComments] = useState<Comment[]>([]);
- const [playing, setPlaying] = useState(false);
- // Toggle annotation + speech bubble visibility per video in compare mode
- const [showMainAnnotations, setShowMainAnnotations] = useState(true);
- const [showCompareAnnotations, setShowCompareAnnotations] = useState(true);
- // Video element ref so we can seek directly from comment timestamp clicks
- const mainVideoRef = useRef<HTMLVideoElement>(null);
- const handleCompareSelect = useCallback((compareAssetArg: Asset) => {
- setShowComparePicker(false);
- setCompareMismatch(null);
- const dur1 = asset?.duration ?? 0;
- const dur2 = compareAssetArg.duration ?? 0;
- const fps = asset?.fps ?? compareAssetArg.fps ?? 30;
- const diffFrames = Math.abs(dur1 - dur2) * fps;
- if (diffFrames > 5) {
- setCompareMismatch(
- `Videos differ by ${Math.round(diffFrames)} frames. Cannot compare — timing mismatch.`
- );
- // Show mismatch banner but don't enter compare mode
- setCompareAsset(compareAssetArg);
- setCompareMode(true);
- return;
- }
- setCompareAsset(compareAssetArg);
- setCompareMode(true);
- // Fetch compare asset's own comments for per-video annotations
- if (token) {
- commentsApi.list(token, compareAssetArg.id).then(({ comments: cc }) => {
- setCompareComments(cc);
- }).catch(() => setCompareComments([]));
- }
- }, [asset, token]);
- const handleExitCompare = useCallback(() => {
- setCompareMode(false);
- setCompareAsset(null);
- setCompareMismatch(null);
- setCompareComments([]);
- }, []);
- useEffect(() => {
- const mq = window.matchMedia('(orientation: portrait)');
- setIsPortrait(mq.matches);
- const handler = (e: MediaQueryListEvent) => setIsPortrait(e.matches);
- mq.addEventListener('change', handler);
- return () => mq.removeEventListener('change', handler);
- }, []);
- const isDraggingRef = useRef(false);
- const panelRef = useRef<HTMLDivElement>(null);
- const resizeStartRef = useRef<{ x: number; w: number } | null>(null);
- // Ref to capture strokes for save callback (avoids closure stale value)
- const pendingStrokesRef = useRef<AnnotationData[]>([]);
- const annotatingCommentRef = useRef<Comment | null>(null);
- // Keep refs in sync with state
- useEffect(() => { pendingStrokesRef.current = pendingStrokes; }, [pendingStrokes]);
- useEffect(() => { annotatingCommentRef.current = annotatingComment; }, [annotatingComment]);
- const fps = asset?.fps ?? 30;
- // Derive the current user's project role
- const currentUserRole = asset?.project.members.find(m => m.user.id === user?.id)?.role;
- const isProjectAdmin = currentUserRole === 'ADMIN';
- const isProjectOwner = asset?.project.ownerId === user?.id;
- const canComment: boolean | undefined = !!(currentUserRole && currentUserRole !== 'VIEWER');
- // ── Poll for transcode progress ───────────────────────────────────────────
- const isTranscoding = asset?.transcodeStatus === 'COMPLETED';
- const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
- useEffect(() => {
- if (isTranscoding) {
- if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
- return;
- }
- if (pollRef.current) return;
- pollRef.current = setInterval(async () => {
- if (!token) return;
- try {
- const { asset: updated } = await assetsApi.getStatus(token, assetId);
- setAsset(prev => prev ? { ...prev, ...updated } : prev);
- } catch {}
- }, 2000);
- return () => { if (pollRef.current) clearInterval(pollRef.current); };
- }, [token, assetId, isTranscoding]);
- // Load asset + comments
- const loadData = useCallback(async () => {
- if (!token) return;
- try {
- const [{ asset: a }, { comments: c }] = await Promise.all([
- assetsApi.get(token, assetId),
- commentsApi.list(token, assetId),
- ]);
- setAsset(a);
- setComments(c);
- } catch {
- router.push('/projects');
- } finally {
- setLoading(false);
- }
- }, [token, assetId, router]);
- useEffect(() => { loadData(); }, [loadData]);
- // ── Panel resize ─────────────────────────────────────────────────────────
- const handlePointerMove = useCallback((e: PointerEvent) => {
- if (!isDraggingRef.current || !resizeStartRef.current) return;
- const dx = e.clientX - resizeStartRef.current.x;
- setPanelWidth(Math.max(280, Math.min(600, resizeStartRef.current.w - dx)));
- }, []);
- const handlePointerUp = useCallback(() => {
- isDraggingRef.current = false;
- resizeStartRef.current = null;
- document.body.style.cursor = '';
- }, []);
- useEffect(() => {
- window.addEventListener('pointermove', handlePointerMove);
- window.addEventListener('pointerup', handlePointerUp);
- return () => {
- window.removeEventListener('pointermove', handlePointerMove);
- window.removeEventListener('pointerup', handlePointerUp);
- };
- }, [handlePointerMove, handlePointerUp]);
- const handleResizeStart = (e: React.PointerEvent) => {
- e.preventDefault();
- isDraggingRef.current = true;
- resizeStartRef.current = { x: e.clientX, w: panelWidth };
- document.body.style.cursor = 'col-resize';
- };
- // ── Comment actions ───────────────────────────────────────────────────────
- const handleAddComment = async (content: string, timestamp?: number, annotations?: AnnotationData[]) => {
- if (!token || !content.trim()) return;
- setSubmitting(true);
- try {
- const { comment } = await commentsApi.create(token, assetId, {
- content: content.trim(),
- timestamp,
- annotations,
- parentId: replyTo?.id,
- });
- if (replyTo) {
- setComments(prev => prev.map(c =>
- c.id === replyTo.id
- ? { ...c, replies: [...(c.replies ?? []), comment] }
- : c
- ));
- } else {
- setComments(prev => [...prev, comment]);
- }
- setNewComment('');
- setPendingStrokes([]);
- setReplyTo(null);
- } catch (err) {
- alert(err instanceof Error ? err.message : 'Failed to add comment');
- } finally {
- setSubmitting(false);
- }
- };
- const handleResolve = async (commentId: string, action: 'approve' | 'reject') => {
- if (!token) return;
- try {
- const { comment } = await commentsApi.resolve(token, commentId, action);
- setComments(prev => prev.map(c => c.id === commentId ? comment : c));
- } catch (err) {
- alert(err instanceof Error ? err.message : 'Failed to update comment');
- }
- };
- const handleRequestResolve = async (commentId: string) => {
- if (!token) return;
- try {
- const { comment } = await commentsApi.requestResolve(token, commentId);
- setComments(prev => prev.map(c => c.id === commentId ? comment : c));
- } catch (err) {
- alert(err instanceof Error ? err.message : 'Failed to request resolve');
- }
- };
- const handleDeleteComment = async (commentId: string) => {
- if (!token) return;
- // Soft delete — just mark hidden, owner can restore
- try {
- await commentsApi.delete(token, commentId);
- setComments(prev => prev.map(c =>
- c.id === commentId ? { ...c, deleted: true } : c
- ));
- } catch {
- alert('Failed to hide comment');
- }
- };
- const handleRestoreComment = async (commentId: string) => {
- if (!token) return;
- try {
- const { comment } = await commentsApi.restoreComment(token, commentId);
- setComments(prev => prev.map(c => c.id === commentId ? comment : c));
- } catch {
- alert('Failed to restore comment');
- }
- };
- // ── Annotation actions ─────────────────────────────────────────────────────
- // User clicks "Add annotation" on a comment — enter draw mode, annotate at current time
- const handleAddAnnotationClick = (comment: Comment) => {
- const existingCount = comment.annotations?.length ?? 0;
- if (existingCount >= MAX_ANNOTATIONS) {
- alert(`Maximum ${MAX_ANNOTATIONS} annotations per comment.`);
- return;
- }
- setPendingStrokes([]);
- setAnnotatingComment(comment);
- setDrawMode(true);
- };
- // Each completed stroke is added to pendingStrokes
- const handleStrokeComplete = (stroke: AnnotationData) => {
- setPendingStrokes(prev => {
- const next = [...prev, stroke];
- if (next.length >= MAX_ANNOTATIONS) {
- setDrawMode(false);
- }
- return next;
- });
- };
- // Save pending strokes as annotation on the parent comment (no separate reply)
- const handleSaveAnnotations = () => {
- const strokes = pendingStrokesRef.current;
- const parent = annotatingCommentRef.current;
- if (!token || !parent || strokes.length === 0) {
- setPendingStrokes([]);
- setDrawMode(false);
- setAnnotatingComment(null);
- return;
- }
- setSubmitting(true);
- setPendingStrokes([]);
- setDrawMode(false);
- setAnnotatingComment(null);
- commentsApi.updateAnnotations(token, parent.id, strokes).then(({ comment }) => {
- setComments(prev => prev.map(c => c.id === parent.id ? comment : c));
- }).catch(err => alert(err instanceof Error ? err.message : 'Failed to save annotation')).finally(() => setSubmitting(false));
- };
- // Discard pending strokes
- const handleUndoAnnotations = () => {
- setPendingStrokes([]);
- setDrawMode(false);
- setAnnotatingComment(null);
- };
- // Delete a single annotation from a comment (owner only)
- const handleDeleteAnnotation = async (commentId: string, remainingAnnotations: AnnotationData[]) => {
- if (!token) return;
- try {
- const { comment } = await commentsApi.updateAnnotations(token, commentId, remainingAnnotations);
- setComments(prev => prev.map(c => c.id === commentId ? comment : c));
- } catch {
- alert('Failed to delete annotation');
- }
- };
- const handleStatusUpdate = async (status: string) => {
- if (!token) return;
- setUpdatingStatus(true);
- try {
- const { asset: updated } = await assetsApi.updateStatus(token, assetId, status);
- setAsset(prev => prev ? { ...prev, status: updated.status } : prev);
- setShowApproval(false);
- } catch {
- alert('Failed to update status');
- } finally {
- setUpdatingStatus(false);
- }
- };
- const handleTimeUpdate = useCallback((time: number) => {
- setCurrentTime(time);
- }, []);
- const handleCommentSeek = useCallback((comment: Comment) => {
- const time = comment.timestamp ?? 0;
- setCurrentTime(time);
- if (mainVideoRef.current) {
- mainVideoRef.current.pause();
- mainVideoRef.current.currentTime = time;
- }
- }, []);
- const status = asset?.status ?? 'PENDING_REVIEW';
- const statusCfg = STATUS_CONFIG[status];
- const transcodeCfg = asset ? TRANSCODE_CONFIG[asset.transcodeStatus] : null;
- const videoUrl = asset?.hlsPath
- ? `${API_BASE}/uploads${asset.hlsPath}`
- : asset
- ? `${API_BASE}/uploads/${asset.filePath}`
- : '';
- const allComments = comments.flatMap(c => [c, ...(c.replies ?? [])]);
- const visibleComments = comments.filter(c => !c.deleted && (showResolved || !c.resolved));
- // Seek to previous/next comment (defined here so they can reference visibleComments)
- const handlePrevComment = useCallback(() => {
- const ts = visibleComments
- .filter(c => c.timestamp != null)
- .map(c => c.timestamp as number)
- .sort((a, b) => b - a);
- const prev = ts.find(t => t < currentTime - 0.3);
- if (prev !== undefined) handleCommentSeek({ timestamp: prev } as Comment);
- }, [visibleComments, currentTime, handleCommentSeek]);
- const handleNextComment = useCallback(() => {
- const ts = visibleComments
- .filter(c => c.timestamp != null)
- .map(c => c.timestamp as number)
- .sort((a, b) => a - b);
- const next = ts.find(t => t > currentTime + 0.3);
- if (next !== undefined) handleCommentSeek({ timestamp: next } as Comment);
- }, [visibleComments, currentTime, handleCommentSeek]);
- // Only main comments (not replies, not deleted) have annotations that should show on the video
- const visibleAnnotations = visibleComments
- .filter(c => !c.deleted)
- .flatMap(c =>
- (c.annotations ?? []).map(ann => ({ annotation: ann, timestamp: c.timestamp ?? 0 }))
- );
- // Annotations for the compare video — independent per-video data
- const compareVisibleComments = compareComments.filter(c => !c.deleted && (showResolved || !c.resolved));
- const compareVisibleAnnotations = compareVisibleComments
- .filter(c => !c.deleted)
- .flatMap(c =>
- (c.annotations ?? []).map(ann => ({ annotation: ann, timestamp: c.timestamp ?? 0 }))
- );
- if (loading) {
- return (
- <div className="h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
- <div className="flex items-center gap-3" style={{ color: 'var(--text-muted)' }}>
- <div className="w-5 h-5 rounded-full animate-spin"
- style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
- <span className="text-sm">Loading review…</span>
- </div>
- </div>
- );
- }
- if (!asset) return null;
- return (
- <div className="h-screen flex flex-col overflow-hidden" style={{ background: 'var(--bg)' }}>
- {/* ── Top bar ──────────────────────────────────────────── */}
- <header className="h-12 flex items-center px-4 gap-3 shrink-0"
- style={{ background: 'rgba(10,11,20,0.95)', borderBottom: '1px solid rgba(255,255,255,0.06)', zIndex: 50 }}>
- <button
- onClick={() => router.push(`/projects/${asset.projectId}`)}
- className="flex items-center gap-1.5 text-xs transition-colors shrink-0"
- style={{ color: 'var(--text-muted)' }}
- >
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
- </svg>
- <span className="hidden sm:inline">Back</span>
- </button>
- <div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
- <div className="flex-1 min-w-0">
- <h1 className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>{asset.title}</h1>
- </div>
- <span className="text-xs hidden sm:inline shrink-0" style={{ color: 'var(--text-subtle)' }}>
- {asset.project?.name}
- </span>
- <div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
- {/* Download */}
- <a
- href={`${API_BASE}/uploads/${asset.filePath}`}
- download={asset.filename}
- className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md transition-all shrink-0"
- style={{ color: '#60A5FA', background: 'rgba(96,165,250,0.08)' }}
- title="Download original video"
- >
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
- </svg>
- <span className="hidden sm:inline">Download</span>
- </a>
- <div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
- {/* Compare mode toggle */}
- <button
- onClick={() => {
- if (compareMode) {
- handleExitCompare();
- } else {
- setShowComparePicker(true);
- if (token && asset) {
- assetsApi.list(token, asset.projectId).then(({ assets }) => {
- setProjectAssets(assets.filter(a => a.id !== assetId && a.transcodeStatus === 'COMPLETED'));
- }).catch(() => {});
- }
- }
- }}
- className={`flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md transition-all shrink-0 ${
- compareMode
- ? 'bg-indigo-600 text-white'
- : ''
- }`}
- style={!compareMode ? { color: '#818CF8', background: 'rgba(129,140,248,0.10)' } : {}}
- title="Side-by-side comparison"
- >
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
- </svg>
- <span className="hidden sm:inline">{compareMode ? 'Exit Compare' : 'Compare'}</span>
- </button>
- {/* Status selector */}
- <div className="relative shrink-0">
- <button
- onClick={() => setShowApproval(v => !v)}
- className="flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-md transition-all"
- style={{ background: statusCfg.bgClass.replace('badge-', 'rgba(').replace('warning', '245,158,11,0.15)').replace('success', '34,197,94,0.15)').replace('danger', '239,68,68,0.15)'), color: statusCfg.colorClass }}
- >
- <span className={`status-dot ${statusCfg.dotClass}`} />
- <span className="hidden sm:inline">{statusCfg.label}</span>
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
- </svg>
- </button>
- {showApproval && (
- <>
- <div className="fixed inset-0 z-40" onClick={() => setShowApproval(false)} />
- <div className="absolute right-0 top-full mt-2 z-50 rounded-xl overflow-hidden"
- style={{ background: '#1E2030', border: '1px solid rgba(255,255,255,0.10)', boxShadow: 'var(--shadow-panel)', minWidth: '200px' }}>
- {Object.entries(STATUS_CONFIG).map(([key, cfg]) => (
- <button
- key={key}
- onClick={() => handleStatusUpdate(key)}
- disabled={updatingStatus}
- className="w-full flex items-center gap-2.5 px-4 py-2.5 text-xs transition-colors hover:bg-white/5"
- style={{ color: key === status ? cfg.colorClass : 'var(--text)' }}
- >
- <span className={`status-dot ${cfg.dotClass}`} />
- <span className="flex-1 text-left">{cfg.label}</span>
- {key === status && (
- <svg className="w-3.5 h-3.5" style={{ color: '#6366F1' }} fill="currentColor" viewBox="0 0 20 20">
- <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
- </svg>
- )}
- </button>
- ))}
- </div>
- </>
- )}
- </div>
- </header>
- {/* ── Compare picker modal ─────────────────────────────────────────────── */}
- {showComparePicker && (
- <>
- <div className="fixed inset-0 z-50" onClick={() => setShowComparePicker(false)} />
- <div
- className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 rounded-2xl overflow-hidden w-full max-w-md"
- style={{ background: '#1E2030', border: '1px solid rgba(255,255,255,0.10)', boxShadow: 'var(--shadow-modal)' }}
- >
- <div className="px-5 py-4 flex items-center justify-between" style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
- <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>Select video to compare</h2>
- <button onClick={() => setShowComparePicker(false)} className="w-7 h-7 flex items-center justify-center rounded-lg transition-colors hover:bg-white/10"
- style={{ color: 'var(--text-muted)' }}>
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
- </svg>
- </button>
- </div>
- <div className="p-2 max-h-80 overflow-y-auto">
- {projectAssets.length === 0 ? (
- <p className="text-sm text-center py-8" style={{ color: 'var(--text-muted)' }}>
- No other completed videos in this project.
- </p>
- ) : (
- projectAssets.map(a => (
- <button
- key={a.id}
- onClick={() => handleCompareSelect(a)}
- className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left transition-colors hover:bg-white/5"
- >
- {a.thumbnail ? (
- <img src={`${API_BASE}/uploads/${a.thumbnail}`} className="w-16 h-10 rounded-lg object-cover shrink-0" alt={a.title} />
- ) : (
- <div className="w-16 h-10 rounded-lg shrink-0 flex items-center justify-center" style={{ background: 'rgba(255,255,255,0.06)' }}>
- <svg className="w-5 h-5" style={{ color: 'rgba(255,255,255,0.2)' }} fill="currentColor" viewBox="0 0 24 24">
- <path d="M8 5v14l11-7z" />
- </svg>
- </div>
- )}
- <div className="flex-1 min-w-0">
- <p className="text-sm font-medium truncate" style={{ color: 'var(--text)' }}>{a.title}</p>
- <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
- {a.duration ? `${Math.floor(a.duration / 60)}:${Math.floor(a.duration % 60).toString().padStart(2, '0')}` : '—'}
- {' · '}
- {a.filename}
- </p>
- </div>
- </button>
- ))
- )}
- </div>
- </div>
- </>
- )}
- {/* ── Body ───────────────────────────────────────────── */}
- {/* Landscape: side-by-side | Portrait: stacked (video top, comments bottom) */}
- <div
- className="flex flex-1 overflow-hidden"
- style={isPortrait
- ? { flexDirection: 'column', overflowY: 'auto' }
- : { flexDirection: 'row' }}
- >
- {/* Video area */}
- <div
- className="overflow-y-auto p-3 sm:p-4 flex flex-col gap-3 min-w-0"
- style={isPortrait
- ? { flex: 'none', width: '100%', minHeight: '45vh' }
- : { flex: 1, overflowY: 'auto' }}
- >
- {/* ── Side-by-side compare layout ───────────────────────── */}
- {compareMode ? (
- <div className="flex gap-2 w-full flex-1 min-h-0">
- {/* Main video + its comments */}
- <div className="flex-1 min-w-0 flex flex-col gap-0 min-h-0">
- {/* Annotation toggle */}
- <div className="flex items-center gap-2 mb-1 px-1">
- <button
- onClick={() => setShowMainAnnotations(v => !v)}
- className="flex items-center gap-1.5 text-[11px] px-2 py-1 rounded-md transition-colors"
- style={showMainAnnotations
- ? { background: 'rgba(99,102,241,0.15)', color: '#818CF8' }
- : { background: 'rgba(255,255,255,0.05)', color: 'var(--text-subtle)' }}
- title={showMainAnnotations ? 'Hide annotations' : 'Show annotations'}
- >
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
- </svg>
- Annot.
- </button>
- </div>
- <div className="text-xs mb-1 px-1 truncate" style={{ color: 'rgba(255,255,255,0.5)' }}>
- {asset.title}
- </div>
- <div className="flex-1 min-h-0 flex flex-col gap-0">
- <VideoPlayer
- src={videoUrl}
- mimeType={asset.mimeType}
- fps={fps}
- comments={showMainAnnotations ? allComments : []}
- visibleAnnotations={showMainAnnotations ? visibleAnnotations : []}
- drawMode={drawMode}
- drawTool={drawTool}
- drawColor={drawColor}
- onDrawModeChange={setDrawMode}
- onDrawToolChange={setDrawTool}
- onDrawColorChange={setDrawColor}
- pendingStrokes={pendingStrokes}
- onStrokeComplete={handleStrokeComplete}
- onTimeUpdate={handleTimeUpdate}
- onCommentClick={handleCommentSeek}
- onPlayingChange={setPlaying}
- onTimelineSeek={handleTimeUpdate}
- externalCurrentTime={currentTime}
- externalPlaying={playing}
- videoRef={mainVideoRef}
- onPrevComment={handlePrevComment}
- onNextComment={handleNextComment}
- thumbnailSrc={videoUrl}
- thumbnailMimeType={asset.mimeType}
- />
- {/* Comments below main video — full available height */}
- <div className="mt-2 rounded-xl flex-1 min-h-0 flex flex-col overflow-hidden" style={{ background: 'rgba(10,11,20,0.80)', border: '1px solid rgba(255,255,255,0.06)' }}>
- <div className="px-3 py-2 shrink-0 flex items-center gap-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
- <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>
- Comments
- </span>
- <span className="text-xs px-1.5 py-0.5 rounded-full" style={{ background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}>
- {visibleComments.length}
- </span>
- <span className="font-mono text-[11px] ml-auto" style={{ color: '#818CF8' }}>
- {formatTimecode(currentTime, fps, asset?.duration ?? 0)}
- </span>
- </div>
- <div className="flex-1 overflow-y-auto scroll-area">
- {visibleComments.length === 0 ? (
- <p className="text-xs text-center py-4" style={{ color: 'var(--text-muted)' }}>No comments</p>
- ) : (
- visibleComments.map(comment => (
- <div key={comment.id} className="px-3 py-2.5 flex items-start gap-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
- <Avatar name={comment.user?.name ?? 'U'} size="xs" />
- <div className="flex-1 min-w-0">
- <div className="flex items-center gap-1.5 mb-0.5">
- <span className="text-[11px] font-medium" style={{ color: 'var(--text)' }}>{comment.user?.name ?? 'Unknown'}</span>
- {comment.timestamp != null && (
- <span className="text-[10px] font-mono px-1 rounded" style={{ background: 'rgba(99,102,241,0.10)', color: '#818CF8' }}>
- {formatTimecode(comment.timestamp, fps, asset?.duration ?? 0)}
- </span>
- )}
- </div>
- <p className="text-[11px] leading-relaxed" style={{ color: 'var(--text-muted)' }}>{comment.content}</p>
- </div>
- </div>
- ))
- )}
- </div>
- </div>
- </div>
- </div>
- {/* Compare video + its comments — only show when durations match */}
- {compareAsset && !compareMismatch && (
- <div className="flex-1 min-w-0 flex flex-col gap-0 min-h-0">
- {/* Annotation toggle */}
- <div className="flex items-center gap-2 mb-1 px-1">
- <button
- onClick={() => setShowCompareAnnotations(v => !v)}
- className="flex items-center gap-1.5 text-[11px] px-2 py-1 rounded-md transition-colors"
- style={showCompareAnnotations
- ? { background: 'rgba(99,102,241,0.15)', color: '#818CF8' }
- : { background: 'rgba(255,255,255,0.05)', color: 'var(--text-subtle)' }}
- title={showCompareAnnotations ? 'Hide annotations' : 'Show annotations'}
- >
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
- </svg>
- Annot.
- </button>
- </div>
- <div className="text-xs mb-1 px-1 truncate" style={{ color: 'rgba(255,255,255,0.5)' }}>
- {compareAsset.title}
- </div>
- <div className="flex-1 min-h-0 flex flex-col gap-0">
- <VideoPlayer
- src={compareAsset.hlsPath ? `${API_BASE}/uploads${compareAsset.hlsPath}` : `${API_BASE}/uploads/${compareAsset.filePath}`}
- mimeType={compareAsset.mimeType}
- fps={compareAsset.fps ?? 30}
- comments={showCompareAnnotations ? compareComments : []}
- visibleAnnotations={showCompareAnnotations ? compareVisibleAnnotations : []}
- drawMode={false}
- drawTool={drawTool}
- drawColor={drawColor}
- onDrawModeChange={() => {}}
- onDrawToolChange={() => {}}
- onDrawColorChange={() => {}}
- pendingStrokes={[]}
- onStrokeComplete={() => {}}
- onTimeUpdate={() => {}}
- onCommentClick={() => {}}
- isComparePlayer={true}
- externalCurrentTime={currentTime}
- externalPlaying={playing}
- thumbnailSrc={compareAsset.hlsPath ? `${API_BASE}/uploads${compareAsset.hlsPath}` : `${API_BASE}/uploads/${compareAsset.filePath}`}
- thumbnailMimeType={compareAsset.mimeType}
- />
- {/* Comments below compare video — full available height */}
- <div className="mt-2 rounded-xl flex-1 min-h-0 flex flex-col overflow-hidden" style={{ background: 'rgba(10,11,20,0.80)', border: '1px solid rgba(255,255,255,0.06)' }}>
- <div className="px-3 py-2 shrink-0 flex items-center gap-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
- <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>
- Comments
- </span>
- <span className="text-xs px-1.5 py-0.5 rounded-full" style={{ background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}>
- {compareVisibleComments.length}
- </span>
- </div>
- <div className="flex-1 overflow-y-auto scroll-area">
- {compareVisibleComments.length === 0 ? (
- <p className="text-xs text-center py-4" style={{ color: 'var(--text-muted)' }}>No comments</p>
- ) : (
- compareVisibleComments.map(comment => (
- <div key={comment.id} className="px-3 py-2.5 flex items-start gap-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
- <Avatar name={comment.user?.name ?? 'U'} size="xs" />
- <div className="flex-1 min-w-0">
- <div className="flex items-center gap-1.5 mb-0.5">
- <span className="text-[11px] font-medium" style={{ color: 'var(--text)' }}>{comment.user?.name ?? 'Unknown'}</span>
- {comment.timestamp != null && (
- <span className="text-[10px] font-mono px-1 rounded" style={{ background: 'rgba(99,102,241,0.10)', color: '#818CF8' }}>
- {formatTimecode(comment.timestamp, fps, asset?.duration ?? 0)}
- </span>
- )}
- </div>
- <p className="text-[11px] leading-relaxed" style={{ color: 'var(--text-muted)' }}>{comment.content}</p>
- </div>
- </div>
- ))
- )}
- </div>
- </div>
- </div>
- </div>
- )}
- </div>
- ) : (
- /* ── Normal single-video layout ─────────────────────────── */
- <VideoPlayer
- src={videoUrl}
- mimeType={asset.mimeType}
- fps={fps}
- comments={allComments}
- visibleAnnotations={visibleAnnotations}
- drawMode={drawMode}
- drawTool={drawTool}
- drawColor={drawColor}
- onDrawModeChange={setDrawMode}
- onDrawToolChange={setDrawTool}
- onDrawColorChange={setDrawColor}
- pendingStrokes={pendingStrokes}
- onStrokeComplete={handleStrokeComplete}
- onTimeUpdate={handleTimeUpdate}
- onCommentClick={handleCommentSeek}
- onPlayingChange={setPlaying}
- videoRef={mainVideoRef}
- onPrevComment={handlePrevComment}
- onNextComment={handleNextComment}
- thumbnailSrc={videoUrl}
- thumbnailMimeType={asset.mimeType}
- />
- )}
- {/* ── Compare mismatch warning ─────────────────────────── */}
- {compareMode && compareMismatch && (
- <div className="rounded-xl px-4 py-3 text-xs flex items-center gap-3"
- style={{ background: 'rgba(251,191,36,0.10)', border: '1px solid rgba(251,191,36,0.25)', color: '#FCD34D' }}>
- <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
- </svg>
- <span className="flex-1">{compareMismatch}</span>
- <button
- onClick={handleExitCompare}
- className="shrink-0 px-2 py-1 rounded-md transition-colors"
- style={{ background: 'rgba(251,191,36,0.15)', color: '#FCD34D' }}
- >
- Cancel
- </button>
- </div>
- )}
- {/* Transcode status overlay — shown when video is not ready */}
- {transcodeCfg && asset.transcodeStatus !== 'COMPLETED' && (
- <div className="mt-3 rounded-xl p-4 flex items-center gap-4"
- style={{ background: transcodeCfg.bg, border: `1px solid ${transcodeCfg.color}30` }}>
- {transcodeCfg.spinner ? (
- <div className="w-8 h-8 rounded-full animate-spin shrink-0"
- style={{ borderColor: transcodeCfg.color, borderTopColor: 'transparent', borderWidth: '2.5px' }} />
- ) : asset.transcodeStatus === 'FAILED' ? (
- <div className="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
- style={{ background: 'rgba(248,113,113,0.15)' }}>
- <svg className="w-4 h-4" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
- </svg>
- </div>
- ) : (
- <div className="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
- style={{ background: 'rgba(251,191,36,0.15)' }}>
- <svg className="w-4 h-4" style={{ color: '#FBBF24' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
- </svg>
- </div>
- )}
- <div className="flex-1 min-w-0">
- <div className="flex items-center gap-2 mb-1">
- <span className="text-sm font-medium" style={{ color: transcodeCfg.color }}>
- {transcodeCfg.label}
- </span>
- {asset.transcodeStatus === 'PROCESSING' && asset.transcodeProgress > 0 && (
- <span className="text-xs font-mono" style={{ color: transcodeCfg.color }}>
- {asset.transcodeProgress}%
- </span>
- )}
- </div>
- {asset.transcodeStatus === 'PROCESSING' && (
- <div className="w-full h-1 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.08)' }}>
- <div
- className="h-full rounded-full transition-all duration-500"
- style={{ width: `${asset.transcodeProgress}%`, background: transcodeCfg.color }}
- />
- </div>
- )}
- {asset.transcodeStatus === 'FAILED' && asset.transcodeError && (
- <p className="text-xs mt-1" style={{ color: '#F87171' }}>
- {asset.transcodeError}
- </p>
- )}
- {asset.transcodeStatus === 'UNSUPPORTED_CODEC' && (
- <p className="text-xs mt-1" style={{ color: '#FB923C' }}>
- {asset.codec ? `Source codec "${asset.codec.toUpperCase()}" — will re-encode to H.264/AAC` : 'Re-encoding to browser-compatible format…'}
- </p>
- )}
- {asset.transcodeStatus === 'PROCESSING' && asset.codec && (
- <p className="text-xs mt-1" style={{ color: '#94A3B8' }}>
- Converting from {asset.codec.toUpperCase()} → H.264/AAC
- </p>
- )}
- {asset.transcodeStatus === 'UPLOADING' && (
- <p className="text-xs mt-1" style={{ color: '#94A3B8' }}>
- Video uploaded — queued for processing
- </p>
- )}
- </div>
- </div>
- )}
- {/* Keyboard shortcuts */}
- {!compareMode && (
- <div className="flex flex-wrap gap-3 text-xs shrink-0 hidden sm:flex" style={{ color: 'var(--text-subtle)' }}>
- <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>Space</kbd> play/pause</span>
- <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>←</kbd><kbd className="px-1.5 py-0.5 rounded text-[10px] ml-0.5" style={{ background: 'rgba(255,255,255,0.06)' }}>→</kbd> ±1 frame <kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>⇧←</kbd><kbd className="px-1.5 py-0.5 rounded text-[10px] ml-0.5" style={{ background: 'rgba(255,255,255,0.06)' }}>⇧→</kbd> ±1s</span>
- <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>C</kbd> draw mode</span>
- <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>Esc</kbd> exit draw</span>
- <span className="font-mono text-[11px]">{formatTimecode(currentTime, fps, asset?.duration ?? 0)}</span>
- </div>
- )}
- </div>
- {/* Resize handle — visible grip bar with 3-dot pattern, wider hit area */}
- {!isPortrait && !compareMode && !commentPanelCollapsed && (
- <div
- onPointerDown={handleResizeStart}
- className="shrink-0 group relative cursor-col-resize select-none"
- style={{ width: 12 }}
- title="Drag to resize"
- >
- {/* Invisible wide hit area (wider than visual) */}
- <div className="absolute inset-y-0" style={{ width: 24, left: -6 }} />
- {/* Visual grip bar */}
- <div className="absolute inset-y-0 left-1/2 -translate-x-1/2 flex flex-col items-center justify-center gap-1.5" style={{ width: 2 }}>
- {[0, 1, 2].map(i => (
- <div
- key={i}
- className="w-1 rounded-full transition-colors"
- style={{
- height: 16,
- background: 'rgba(255,255,255,0.18)',
- }}
- />
- ))}
- </div>
- {/* Highlight on drag */}
- <div
- className="absolute inset-y-0 left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity"
- style={{ width: 2, background: 'rgba(99,102,241,0.5)' }}
- />
- </div>
- )}
- {/* Floating expand button when panel is collapsed */}
- {!isPortrait && !compareMode && commentPanelCollapsed && (
- <button
- onClick={() => setCommentPanelCollapsed(false)}
- className="shrink-0 flex items-center justify-center w-8 self-stretch rounded-l-lg transition-all hover:bg-white/10 active:scale-95"
- style={{ background: 'rgba(10,11,20,0.90)', borderLeft: '1px solid rgba(255,255,255,0.06)' }}
- title="Expand comments panel"
- >
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} style={{ color: 'var(--text-muted)' }}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
- </svg>
- </button>
- )}
- {/* ── Comment panel — hidden in compare mode (comments are below each video) ── */}
- {!compareMode && (
- <div
- ref={panelRef}
- className={`flex flex-col shrink-0 transition-all duration-300 ease-in-out ${commentPanelCollapsed && !isPortrait ? 'comment-panel-collapsed' : ''}`}
- style={isPortrait
- ? {
- flex: 1,
- width: '100%',
- minHeight: '55vh',
- background: 'rgba(10,11,20,0.98)',
- borderTop: '1px solid rgba(255,255,255,0.06)',
- }
- : {
- width: panelWidth,
- background: 'rgba(10,11,20,0.98)',
- borderLeft: '1px solid rgba(255,255,255,0.06)',
- }}
- >
- {/* Panel header */}
- <div className="px-3 sm:px-4 py-2.5 sm:py-3 flex items-center justify-between shrink-0"
- style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
- <div className="flex items-center gap-2">
- <h2 className="text-[13px] sm:text-sm font-semibold" style={{ color: 'var(--text)' }}>Comments</h2>
- <span className="text-xs px-1.5 py-0.5 rounded-full"
- style={{ background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}>
- {comments.length}
- </span>
- </div>
- <div className="flex items-center gap-2">
- <span className="font-mono text-[11px] sm:text-xs hidden sm:inline" style={{ color: '#818CF8' }}>
- {formatTimecode(currentTime, fps, asset?.duration ?? 0)}
- </span>
- <button
- onClick={() => setShowResolved(v => !v)}
- className={`text-[11px] px-2 py-0.5 rounded-md transition-colors ${showResolved ? 'bg-indigo-600 text-white' : ''}`}
- style={!showResolved ? { background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' } : {}}
- >
- {showResolved ? 'Hide resolved' : 'Show resolved'}
- </button>
- <button
- onClick={() => setCommentPanelCollapsed(v => !v)}
- className="text-[11px] px-2 py-0.5 rounded-md transition-colors"
- style={commentPanelCollapsed
- ? { background: 'rgba(99,102,241,0.20)', color: '#818CF8' }
- : { background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}
- title={commentPanelCollapsed ? 'Expand comments panel' : 'Collapse comments panel'}
- >
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- {commentPanelCollapsed ? (
- // Chevron right — panel is collapsed to the right
- <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
- ) : (
- // Chevron left — panel is expanded
- <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
- )}
- </svg>
- </button>
- {compareMode && (
- <span className="text-[11px] px-2 py-0.5 rounded-md" style={{ background: 'rgba(99,102,241,0.15)', color: '#818CF8' }}>
- Compare mode
- </span>
- )}
- </div>
- </div>
- {/* Drawing mode banner */}
- {drawMode && (
- <div className="px-4 py-2 shrink-0 flex items-center gap-2"
- style={{ background: 'rgba(59,130,246,0.12)', borderBottom: '1px solid rgba(59,130,246,0.2)' }}>
- <svg className="w-4 h-4 shrink-0" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
- </svg>
- <span className="text-xs flex-1" style={{ color: '#818CF8' }}>
- {annotatingComment
- ? `Drawing annotation on "${annotatingComment.user?.name}" — ${pendingStrokes.length}/${MAX_ANNOTATIONS} strokes`
- : `Drawing on video — ${pendingStrokes.length}/${MAX_ANNOTATIONS} strokes`}
- </span>
- <div className="flex items-center gap-1.5">
- <button
- onClick={handleUndoAnnotations}
- className="text-xs px-2 py-0.5 rounded transition-colors"
- style={{ background: 'rgba(239,68,68,0.15)', color: '#FCA5A5' }}
- >
- Undo all
- </button>
- <button
- onClick={handleSaveAnnotations}
- disabled={submitting || pendingStrokes.length === 0}
- className="text-xs px-2 py-0.5 rounded transition-colors disabled:opacity-40"
- style={{ background: 'rgba(34,197,94,0.15)', color: '#86EFAC' }}
- >
- {submitting ? 'Saving…' : 'Save'}
- </button>
- </div>
- </div>
- )}
- {/* Comment list */}
- <div className="flex-1 overflow-y-auto scroll-area">
- {visibleComments.length === 0 ? (
- <div className="flex flex-col items-center justify-center py-16 px-4 text-center">
- <div className="w-12 h-12 rounded-2xl flex items-center justify-center mb-3"
- style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.12)' }}>
- <svg className="w-6 h-6" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
- </svg>
- </div>
- <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>No comments yet</p>
- <p className="text-xs leading-relaxed" style={{ color: 'var(--text-muted)' }}>
- Add a comment below or click <strong>Add annotation</strong> on an existing comment
- </p>
- </div>
- ) : (
- <div>
- {visibleComments.map(comment => (
- <CommentItem
- key={comment.id}
- comment={comment}
- currentUserId={user?.id ?? ''}
- fps={fps}
- duration={asset?.duration ?? 0}
- canComment={canComment}
- isProjectAdmin={isProjectAdmin}
- isProjectOwner={isProjectOwner ?? false}
- onTimestampClick={handleCommentSeek}
- onReply={() => { setReplyTo(comment); }}
- onResolve={(action) => handleResolve(comment.id, action)}
- onRequestResolve={() => handleRequestResolve(comment.id)}
- onDeleteSelf={() => handleDeleteComment(comment.id)}
- onDelete={(id) => handleDeleteComment(id)}
- onAddAnnotation={() => handleAddAnnotationClick(comment)}
- onDeleteAnnotation={(anns) => handleDeleteAnnotation(comment.id, anns)}
- onRestore={handleRestoreComment}
- />
- ))}
- </div>
- )}
- </div>
- {/* New comment / reply input */}
- <div className="shrink-0 p-4"
- style={{ borderTop: '1px solid rgba(255,255,255,0.06)', background: 'rgba(10,11,20,0.80)' }}>
- {replyTo && (
- <div className="flex items-center gap-2 mb-2 text-xs" style={{ color: 'var(--text-muted)' }}>
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
- </svg>
- Replying to {replyTo.user?.name}
- <button onClick={() => setReplyTo(null)} className="ml-auto" style={{ color: 'var(--text-subtle)' }}>
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
- </svg>
- </button>
- </div>
- )}
- {/* Pending strokes indicator */}
- {pendingStrokes.length > 0 && (
- <div className="flex items-center gap-2 mb-2 text-xs" style={{ color: '#818CF8' }}>
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
- </svg>
- {pendingStrokes.length} stroke{pendingStrokes.length !== 1 ? 's' : ''} ready
- {annotatingComment ? ` → annotation on "${annotatingComment.user?.name}"` : ' → will be saved as new comment'}
- <button onClick={handleUndoAnnotations} className="ml-auto text-xs" style={{ color: '#FCA5A5' }}>Undo</button>
- </div>
- )}
- <form
- onSubmit={e => {
- e.preventDefault();
- if (newComment.trim() || pendingStrokes.length > 0) {
- handleAddComment(newComment, currentTime, pendingStrokes.length > 0 ? pendingStrokes : undefined);
- }
- }}
- className="flex gap-2"
- >
- <Avatar name={user?.name ?? 'U'} size="sm" />
- <div className="flex-1 flex gap-2">
- <textarea
- className="input flex-1"
- value={compareMode ? '' : newComment}
- onChange={e => setNewComment(e.target.value)}
- placeholder={compareMode ? 'Comments disabled in compare mode' : replyTo ? 'Write a reply…' : 'Add a comment…'}
- disabled={compareMode}
- readOnly={compareMode}
- rows={1}
- style={{ resize: 'none', overflow: 'hidden' }}
- onKeyDown={e => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- if (newComment.trim() || pendingStrokes.length > 0) {
- handleAddComment(newComment, currentTime, pendingStrokes.length > 0 ? pendingStrokes : undefined);
- }
- }
- }}
- />
- <button
- type="submit"
- disabled={submitting || (!newComment.trim() && pendingStrokes.length === 0)}
- className="btn btn-primary btn-sm px-3"
- >
- {submitting ? (
- <div className="w-3.5 h-3.5 rounded-full animate-spin"
- style={{ borderColor: '#fff', borderTopColor: 'transparent' }} />
- ) : (
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 12h12M6 12l4-4M6 12l4 4" />
- </svg>
- )}
- </button>
- </div>
- </form>
- </div>
- </div>
- )}
- </div>
- </div>
- );
- }
- // ── CommentItem ─────────────────────────────────────────────────────────────
- function CommentItem({
- comment,
- currentUserId,
- fps,
- duration,
- canComment,
- isProjectAdmin,
- isProjectOwner,
- onTimestampClick,
- onReply,
- onResolve,
- onRequestResolve,
- onDeleteSelf,
- onDelete,
- onAddAnnotation,
- onDeleteAnnotation,
- onRestore,
- }: {
- comment: Comment;
- currentUserId: string;
- fps: number;
- duration: number;
- canComment: boolean | undefined;
- isProjectAdmin: boolean;
- isProjectOwner: boolean;
- onTimestampClick: (c: Comment) => void;
- onReply: () => void;
- onResolve: (action: 'approve' | 'reject') => void;
- onRequestResolve: () => void;
- onDeleteSelf: () => void;
- onDelete: (id: string) => void;
- onAddAnnotation: () => void;
- onDeleteAnnotation: (annotations: AnnotationData[]) => void;
- onRestore: (id: string) => void;
- }) {
- const isOwner = comment.userId === currentUserId;
- const isCommentAuthor = comment.userId === currentUserId;
- const name = comment.user?.name ?? 'Unknown';
- const isReply = !!comment.parentId;
- const annotations = comment.annotations ?? [];
- const canAddMore = annotations.length < MAX_ANNOTATIONS;
- const isDeleted = !!comment.deleted;
- const canRestore = !isDeleted && (isProjectOwner || isProjectAdmin);
- // Resolve state machine
- const isResolved = comment.resolveStatus === 'RESOLVED';
- const isPending = comment.resolveStatus === 'PENDING_APPROVAL';
- const canApprove = isCommentAuthor || isProjectAdmin;
- const canRequest = canComment && !isResolved && !isPending && !isCommentAuthor;
- const canReopen = isResolved && canApprove;
- return (
- <div
- className="p-4 animate-fade-in"
- style={{
- opacity: isDeleted ? 0.45 : isResolved ? 0.55 : 1,
- paddingLeft: isReply ? '2.5rem' : undefined,
- borderLeft: isDeleted ? '2px solid rgba(239,68,68,0.3)' : undefined,
- }}
- >
- <div className="flex gap-2.5">
- <Avatar name={name} size="sm" />
- <div className="flex-1 min-w-0">
- {/* Meta row */}
- <div className="flex items-center gap-2 mb-1 flex-wrap">
- <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>{name}</span>
- {comment.timestamp != null && (
- <button
- onClick={() => onTimestampClick(comment)}
- className="text-xs px-1.5 py-0.5 rounded font-mono transition-colors hover:bg-indigo-600/20"
- style={{ background: 'rgba(99,102,241,0.10)', color: '#818CF8', fontSize: '11px' }}
- >
- {formatTimecode(comment.timestamp, fps, duration)}
- </button>
- )}
- {isPending && (
- <span className="text-xs px-1.5 py-0.5 rounded"
- style={{ background: 'rgba(251,191,36,0.12)', color: '#FCD34D' }}>
- Pending approval
- </span>
- )}
- {isResolved && (
- <span className="text-xs px-1.5 py-0.5 rounded"
- style={{ background: 'rgba(34,197,94,0.10)', color: '#86EFAC' }}>
- Approved
- </span>
- )}
- {isResolved && comment.resolvedBy && (
- <span className="text-xs" style={{ color: 'var(--text-subtle)' }}>
- by {comment.resolvedBy.name}
- </span>
- )}
- <span className="text-xs ml-auto" style={{ color: 'var(--text-subtle)' }}>
- {new Date(comment.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
- </span>
- </div>
- {/* Annotation preview badges */}
- {annotations.length > 0 && (
- <div className="flex flex-wrap gap-1 mb-2">
- {annotations.map((ann, i) => (
- <div
- key={i}
- className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded"
- style={{ background: `${ann.color}20`, color: ann.color, border: `1px solid ${ann.color}40` }}
- >
- <svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
- </svg>
- {ann.type}
- {isOwner && (
- <button
- onClick={() => {
- const remaining = annotations.filter((_, j) => j !== i);
- onDeleteAnnotation(remaining);
- }}
- className="ml-0.5 hover:opacity-70 transition-opacity"
- title="Delete this annotation"
- >
- <svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
- </svg>
- </button>
- )}
- </div>
- ))}
- </div>
- )}
- {/* Content */}
- <p className="text-[13px] sm:text-sm leading-relaxed mb-2" style={{ color: 'var(--text-muted)' }}>
- {comment.content}
- </p>
- {/* Actions */}
- <div className="flex items-center gap-1">
- {/* Restore button for soft-deleted comments — project owner/ADMIN only */}
- {isDeleted && (isProjectOwner || isProjectAdmin) && (
- <button
- onClick={() => onRestore(comment.id)}
- className="text-xs px-2 py-1 rounded-md transition-colors"
- style={{ color: '#86EFAC', background: 'rgba(34,197,94,0.10)' }}
- title="Restore this comment"
- >
- <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
- </svg>
- Restore
- </button>
- )}
- {!isReply && !isDeleted && (
- <button
- onClick={onAddAnnotation}
- disabled={!canAddMore}
- className="text-xs px-2 py-1 rounded-md transition-colors disabled:opacity-30"
- style={{ color: '#818CF8' }}
- title={canAddMore ? `Add annotation (${annotations.length}/${MAX_ANNOTATIONS})` : `Max ${MAX_ANNOTATIONS} annotations reached`}
- >
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
- </svg>
- </button>
- )}
- {!isReply && !isDeleted && (
- <button
- onClick={onReply}
- className="text-xs px-2 py-1 rounded-md transition-colors"
- style={{ color: 'var(--text-muted)' }}
- title="Reply"
- >
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
- </svg>
- </button>
- )}
- {!isReply && (
- <button
- onClick={onReply}
- className="text-xs px-2 py-1 rounded-md transition-colors"
- style={{ color: 'var(--text-muted)' }}
- title="Reply"
- >
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
- </svg>
- </button>
- )}
- {/* Resolve / approval workflow buttons */}
- {!isReply && !isDeleted && !isResolved && !isPending && (
- <>
- {canRequest ? (
- <button
- onClick={onRequestResolve}
- className="text-xs px-2 py-1 rounded-md transition-colors"
- style={{ color: '#6366F1' }}
- title="Request resolve approval"
- >
- <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
- </svg>
- Request resolve
- </button>
- ) : (
- <span
- className="text-xs px-2 py-1 opacity-30"
- style={{ color: '#6366F1' }}
- title={!canComment ? 'Viewers cannot request resolve' : isCommentAuthor ? 'Cannot resolve your own comment' : undefined}
- >
- <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
- </svg>
- Request resolve
- </span>
- )}
- </>
- )}
- {isPending && canApprove && !isReply && !isDeleted && (
- <>
- <button
- onClick={() => onResolve('approve')}
- className="text-xs px-2 py-1 rounded-md transition-colors"
- style={{ color: '#86EFAC' }}
- title={`Approve (by ${comment.requestedBy?.name ?? 'someone'})`}
- >
- <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
- </svg>
- Approve
- </button>
- <button
- onClick={() => onResolve('reject')}
- className="text-xs px-2 py-1 rounded-md transition-colors"
- style={{ color: '#FCA5A5' }}
- title="Reject resolve request"
- >
- <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
- </svg>
- Reject
- </button>
- </>
- )}
- {isPending && !canApprove && !isReply && !isDeleted && (
- <span className="text-xs px-2 py-1 opacity-40" style={{ color: '#FCD34D' }} title="Awaiting approval">
- <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
- </svg>
- Awaiting approval
- </span>
- )}
- {canReopen && !isReply && !isDeleted && (
- <button
- onClick={() => onResolve('reject')}
- className="text-xs px-2 py-1 rounded-md transition-colors"
- style={{ color: '#86EFAC' }}
- title="Reopen comment"
- >
- <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
- </svg>
- Reopen
- </button>
- )}
- {isOwner && !isDeleted && (
- <button
- onClick={onDeleteSelf}
- className="text-xs px-2 py-1 rounded-md transition-colors"
- style={{ color: 'var(--text-subtle)' }}
- title="Hide comment"
- >
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
- </svg>
- </button>
- )}
- </div>
- {/* Replies */}
- {comment.replies && comment.replies.length > 0 && (
- <div className="mt-3 space-y-3">
- {comment.replies.map(reply => (
- <ReplyItem
- key={reply.id}
- comment={reply}
- isOwner={reply.userId === currentUserId}
- onDelete={() => onDelete(reply.id)}
- />
- ))}
- </div>
- )}
- </div>
- </div>
- </div>
- );
- }
- // ── ReplyItem ──────────────────────────────────────────────────────────────
- // Replies have no resolve, no annotation, no timestamp — just content + delete
- function ReplyItem({
- comment,
- isOwner,
- onDelete,
- }: {
- comment: Comment;
- isOwner: boolean;
- onDelete: (id: string) => void;
- }) {
- return (
- <div className="flex gap-2.5 animate-fade-in">
- <Avatar name={comment.user?.name ?? 'U'} size="sm" />
- <div className="flex-1 min-w-0">
- <div className="flex items-center gap-2 mb-0.5">
- <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>
- {comment.user?.name ?? 'Unknown'}
- </span>
- <span className="text-xs ml-auto" style={{ color: 'var(--text-subtle)' }}>
- {new Date(comment.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
- </span>
- </div>
- <p className="text-sm leading-relaxed" style={{ color: 'var(--text-muted)' }}>
- {comment.content}
- </p>
- {isOwner && (
- <button
- onClick={() => onDelete(comment.id)}
- className="text-xs mt-1 transition-colors"
- style={{ color: 'var(--text-subtle)' }}
- title="Delete reply"
- >
- Delete
- </button>
- )}
- </div>
- </div>
- );
- }
|